Story 2296: V3 Post Detail page#2373
Conversation
a8e4ca1 to
08602ac
Compare
08602ac to
60eac1a
Compare
Refs PR boostorg#2373 review feedback. --font-mono is not a defined design token; the correct identifier for monospace text is --font-code.
Refs PR boostorg#2373 review feedback.
3fbe4dd to
251defa
Compare
| @register.filter | ||
| def text_paragraphs(value): | ||
| """Render hard-wrapped plain text as autolinked paragraphs. | ||
|
|
||
| Blank lines become paragraph breaks; single newlines inside a | ||
| paragraph collapse to spaces so source hard-wrapped at ~80 chars | ||
| flows naturally to the container width. | ||
| """ | ||
| if not value: | ||
| return "" | ||
| paragraphs = [] | ||
| for chunk in _PARAGRAPH_SPLIT.split(str(value)): | ||
| text = " ".join(line.strip() for line in chunk.splitlines() if line.strip()) | ||
| if text: | ||
| paragraphs.append(f"<p>{urlize(text, autoescape=True)}</p>") | ||
| return mark_safe("\n".join(paragraphs)) |
There was a problem hiding this comment.
Hmm I think forcing single newlines into spaces might break the formatting of some lists in the old posts, please see this example:
- Original template: http://localhost:8000/news/entry/boost-c-libraries-and-fiscal-sponsorship/
- V3 template: http://localhost:8000/v3/news/entry/boost-c-libraries-and-fiscal-sponsorship/
(Notice the numbered list and * list, the single newline on Thank you, was intentional too)
Is it possible to refine the strategy here a bit more to not affect lists? 🤔
There was a problem hiding this comment.
Good catch. I refined this
There was a problem hiding this comment.
Actually, why do we need this at all @julioest? Shouldn't this area just render regular markdown, without the need for any manual customization?
There was a problem hiding this comment.
Yep, y'all are both right. Stripped the manual stuff and switched the body to wagtail-markdown (f7b8b60). Now, the lists, autolinked URLs, and bold/italic/code now render naturally.
Heads up: legacy Thank you,\nName sign-offs collapse to one line since markdown treats single newlines as soft breaks; authors can drop a blank line if they want the break back. Tested this and it renders this -
Adds the V3 post detail page at /v3/news/entry/<slug>/, used by all Entry types (blog, news, link, video, poll). - New V3PostDetailView with real next/related queries. - Reusable _post_header include (title, meta, author block). - Body escapes user content and falls back to summary when content is empty.
Source content hard-wrapped at ~80 chars was rendering a forced <br> on every soft newline. The new filter splits on blank lines into <p> tags and joins single newlines with spaces, so prose flows to the container width. Autolinks URLs and escapes HTML; preserves XSS protection.
- Body typography per Figma: text-secondary, line-height 135%, letter-spacing, paragraph spacing via flex gap. - 1px separator line above body with 32px on each side. - 64px gap between body and first sibling section; 32px between Next Post and Related Post. - Card-group + post-card refinement: page-flush borders, outer-corner rounding only, transparent card-group background in light and dark, subtle per-card background. - Post-header bullet now a CSS-drawn dot. - Spacing fix: Figma "xxl" (32px) maps to --space-xl.
Author role derives from user.maintainers (Maintainer when the
user maintains any LibraryVersion, Contributor otherwise).
Badge picks the user's first Badge and points at the static
convention static/img/v3/badges/badge-{name}.png. Both lookups
are batched via prefetch_related on the entry, next, and
related querysets.
_post_card_item now exposes entry.summary as description so the cards have content for the description slot the PostFeed work in flight will render. No template changes here; the dict is plumbed end-to-end on our side and waits for the PostFeed include to read it.
Link entries render their external URL as the link text, which screen readers spell out character by character. The new aria-label gives a meaningful announcement (post title plus new-tab cue) while keeping the URL visible for sighted users. Focus-visible styling is already covered globally by v3-style-overrides.css; body text contrast meets WCAG AA in both light and dark modes.
Mobile (<768px): - post-header gap raised to --space-large. - 64px between article body and the first sibling section. - 64px page padding-bottom (gap below the last related card). Tablet (768-1279px): - post-header gap raised to --space-large.
The post-header date now reads m/d/Y per design. Next/related card dates still render d/m/Y since that format lives in the shared _post_card.html include, which is also used by the homepage, learn page, and component demo. Updating those is a separate change.
Entry.objects.published() filters published=True but not the soft-delete flag, so deleted posts could surface in the V3 post detail Next Post and Related Posts sections. Filter deleted_at__isnull=True on both querysets.
Add pk as a secondary sort key on the V3 next-post and related-posts queries so results stay deterministic when multiple entries share the same publish_at. Restore the admin-only "entry deleted" notice at the top of the V3 detail article, mirroring the legacy template. Non-admins still 404 via can_view, so the notice only renders for users authorized to view deleted entries.
Move the dict builder for v3/includes/_user_profile.html out of V3PostDetailView into users/profile_cards.py so other views (testimonial card, post card, user card) can reuse it without duplicating the maintainer/badge logic.
text_paragraphs was collapsing every single newline to a space, which destroyed numbered and bullet lists in legacy posts and merged intentional line breaks like sign-offs into the surrounding sentence. Detect author-formatted paragraphs (any list marker, or any non-final line under 60 chars) and preserve their breaks as <br>. Genuine hard-wrapped prose still collapses to flow at the container width.
9b4ec4f to
2b01c95
Compare
Folds V3PostDetailView into EntryDetailView via V3Mixin so the same /news/entry/<slug>/ URL serves either template based on the v3 waffle flag, dropping the temporary /v3/news/entry/ route. Per julhoang's review on PR boostorg#2373. Ports the admin actions row (Approve, Edit, Delete) and Pending Moderation badge into the V3 detail template so moderators keep those controls when the v3 flag is active. EntryModerationDetailView opts out of v3 rendering with v3_template_name = None; iter_v3_views skips views without a v3 template so the registry test ignores the opt-out.
julhoang
left a comment
There was a problem hiding this comment.
Hi @julioest ! I have a couple of additional small requests below. 🙏
Also, I've just realized that while the Create Post is generating markdown (via WYSIWYG), this Post Detail page seems to be expecting rich text – we should agree upon 1 format for this flow. 🤔
| @register.filter | ||
| def text_paragraphs(value): | ||
| """Render hard-wrapped plain text as autolinked paragraphs. | ||
|
|
||
| Blank lines become paragraph breaks; single newlines inside a | ||
| paragraph collapse to spaces so source hard-wrapped at ~80 chars | ||
| flows naturally to the container width. | ||
| """ | ||
| if not value: | ||
| return "" | ||
| paragraphs = [] | ||
| for chunk in _PARAGRAPH_SPLIT.split(str(value)): | ||
| text = " ".join(line.strip() for line in chunk.splitlines() if line.strip()) | ||
| if text: | ||
| paragraphs.append(f"<p>{urlize(text, autoescape=True)}</p>") | ||
| return mark_safe("\n".join(paragraphs)) |
Render the publish date as "May 8th, 2026" (F jS, Y) per
review feedback, and drop the orphan blank line between
the tag span and {% endif %} in _post_header.html.
Per review feedback, swap the post-header title tracking from --letter-spacing-display-regular (-0.02em) to --letter-spacing-tight (-0.01em).
Per review feedback, expose entry.tag under the "category" key in _post_card_item rather than "tag", since the _post_card component uses "tag" for library hashtags (e.g. #beast) which we don't store yet. Also pass the value through TAG_LABELS so next/related cards display "blog" for blogposts, matching the post header.
Move the "has v3 template" filter out of iter_v3_views and into the test that needs it. The discovery function now returns every V3Mixin subclass, including ones that opt out of v3 rendering with v3_template_name = None (like EntryModerationDetailView). The V3 Demo registry can show them again, while the drift test still only verifies real templates load.
Swap text_paragraphs for the standard markdown filter (via wagtail-markdown) plus urlize, so post bodies align with what Create Post outputs from the WYSIWYG and where Wagtail RichTextField is heading. Real <ol> and <ul> lists, autolinked URLs, and bold/italic/code all render for new posts. Add scoped styles for lists, headings, and strong inside .post-detail__body so the rendered markdown picks up the v3 type system. Heads up: legacy "Thank you,\nName" sign-offs now collapse to one line since markdown treats single newlines as soft breaks. Authors can drop a blank line if they want the break back.
Most of .post-detail__body p, the li block, and the margin on h1-h6 were just restating things that already inherit or that the global stylesheet resets. Slim them down, move the shared letter-spacing up to .post-detail__body, and leave a note on the p padding override since frontend/styles.css applies py-5 to every <p> and that would otherwise break the flex gap rhythm here.
Match the post header's date format (e.g. "January 30th, 2026") on next/related cards, the homepage community feed, and the community page. One-line change in the shared _post_card.html include so all three surfaces stay in sync.
Aligns the constant name with the card's `category` key (and the post header's "Blog" / "News" / "Link" display label) instead of the source field name `entry.tag`.
Per design: cards switch from "January 30th, 2026" to "Jan 30th, 2026". One-character format change (F -> M). The post header keeps the full month name since it has more room.
herzog0
left a comment
There was a problem hiding this comment.
Heya sorry for my initial oversight. Just a comment on the next_entry query!
| next_entry = ( | ||
| Entry.objects.published() | ||
| .select_related("author") | ||
| .prefetch_related(*self.AUTHOR_PREFETCH) | ||
| .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) | ||
| .exclude(pk=entry.pk) | ||
| .order_by("publish_at", "pk") | ||
| .first() | ||
| ) |
There was a problem hiding this comment.
Hmm I think the next post should actually be the one relative to the current one, otherwise it'll always show the most recent post for all posts
There was a problem hiding this comment.
So, I dug into this a bit so I can better understand. I ran the same queryset in the dev shell with full timestamps. The query does return the chronologically next post, not the most recent overall.
2023-10-10 10:12:22 "The Boost.Asio property system" -> "CppCon YouTube Channel 100k Subscriber Milestone!"
2023-10-12 14:54:38 "CppCon YouTube Channel 100k Subscriber Milestone!" -> "CppCon 2023 Trip Report"
2023-10-19 17:11:06 "CppCon 2023 Trip Report" -> "Sam's Q3 Projects"
2023-10-25 17:13:24 "Sam's Q3 Projects" -> "Joaquín's Boost.Unordered Update"
2023-10-27 16:50:50 "Joaquín's Boost.Unordered Update" -> "Alan's Work on MrDocs and Handlebars"
2023-10-27 17:16:38 "Alan's Work on MrDocs and Handlebars" -> "Christian's Unordered Update"
2023-10-27 17:17:18 "Christian's Unordered Update" -> "Fernando's Adventures in Boost"
2023-10-27 17:29:32 "Fernando's Adventures in Boost" -> "Klemens Boost.Async"
2023-10-27 17:30:32 "Klemens Boost.Async" -> "Peter Turcan Documentation Status"
2023-10-28 17:20:46 "Peter Turcan Documentation Status" -> "Matt's Charconv and Decimal Update"
The 2023-10-27 cluster makes it clear: five posts within ~40 minutes, each pointing at the next neighbor by timestamp.
This direction matches v2's get_next_by_publish_at, so this feels more like a design call than a bug. Happy to flip it either way though.
Let us know whatcha think @henryajisegiri
There was a problem hiding this comment.
Hey @julioest we can leave this for the integration ticket then. Thanks a ton for the digging. Henry is also aware we're leaving this for later.
It's below the fold for most viewports, so deferring the fetch cuts initial-paint bytes without affecting readers who scroll.
related_qs was meant to filter by libraries linked to the current entry, not "any other published post." That relation doesn't exist in the schema yet, so a TODO captures the intent until it does.
herzog0
left a comment
There was a problem hiding this comment.
Thanks for addressing the issues!

Summary & Context
Implements Story 2296: Webpage UI: Posts Detail. Adds the V3 post detail page, mounted on the polymorphic
Entrymodel so blog posts, news, links, videos, and polls all render through the same layout. Built on the V3 registry pattern from #2308 (V3Mixin). Reuses existing V3 design-system includes (_user_profile,_post_card->_card_group) and adds one new reusable include (_post_header).Figma link:
Link to components/page:
v3waffle flag on)Changes
Routing
/news/entry/<slug:slug>/URL. No new route.EntryDetailViewpicks between the legacy and V3 templates based on thev3waffle flag.View (
news/views.py)EntryDetailViewis nowV3Mixin + DetailView, withtemplate_name = "news/detail.html"andv3_template_name = "news/v3/detail.html". Returns the legacy template by default and the V3 template when the flag is on. Verified bycore.tests.test_v3_registry.Entry.can_viewis unchanged.publish_atis after the current's; excludes self.deleted_at__isnull=Trueand usepkas a secondary sort key for stable ordering whenpublish_atties.select_related("author")andprefetch_related("author__badges", "author__maintainers")to avoid N+1.EntryModerationDetailViewopts out of V3 rendering withv3_template_name = None;iter_v3_views()still surfaces it for the V3 Demo registry, while the drift test filters out opted-out views before checking templates._post_card_itembuilds the dict shape_post_card.htmlexpects._post_card_itemexposesentry.tagunder thecategorykey (capitalized viaTAG_LABELS), since thetagkey on_post_card.htmlis reserved for library hashtags like#beastthat aren't yet in the model.users.profile_cards.user_profile_card, a shared helper that other components (testimonial card, post card, user card) can reuse.TAG_LABELS = {"blogpost": "blog"}so the meta tag renders a friendlier label.user.maintainers(reverse ofLibraryVersion.maintainers): "Maintainer" if the user maintains any library version, otherwise "Contributor".Badge, mapped via the static-image conventionstatic/img/v3/badges/badge-{name}.png. Renders nothing when the user has no badges.Admin chrome on the V3 detail
green/secondary/errorstyles.can_view, so the notice only renders for users authorized to view deleted entries.Body rendering
|markdownfilter chained withurlizefor raw URLs. Real<ol>/<ul>lists, autolinked URLs, and bold/italic/code/headings render naturally for new content authored in the WYSIWYG. Aligns with the post creation flow's markdown output.object.content; falls back toobject.visible_content(AI summary) for Link entries with no content of their own.object.external_urlsurfaces as a link for Link entries.New reusable include (
templates/v3/includes/_post_header.html)title,publish_date, optionaltag, optionalauthor._user_profile.htmlfor the author block.CSS
static/css/v3/post-header.cssandstatic/css/v3/post-detail.css..post-detail__bodyfor lists, headings (h1–h6), bold, and code/pre so markdown output picks up the v3 type system.Body rendering is plain text + autolinking, not markdown. Ticket criterion 4 calls for markdown with Boostlook 2.0 styling. Boostlook 2.0 isn't ready, so this PR leaves markdown for a follow-up. Bold/italic/code-fence syntax in source will appear literally until then.Body now renders markdown. I'm leaving boostlook out of this flow: it's a heavy framework geared toward documentation templates, and the scoped v3 CSS inpost-detail.cssalready handles the markdown formatting we need (paragraphs, lists, headings, code).Entry.objects.published()covers all subtypes. Next/Related can surface BlogPost, Link, News, Video, or Poll entries. If the editorial intent is "blog detail only shows blog-style siblings," that's a follow-up filter.Role rule is a chosen default. "Maintains at least one
LibraryVersion-> Maintainer" is a reasonable starting rule; if the team wants something stricter (current versions only, primary library, etc.) the predicate is one line inuser_profile_card.Badges depend on data. The
Badgemodel has onlyname/display_name(no image field). The helper assemblesstatic/img/v3/badges/badge-{name}.pngfromBadge.namebased on the existing static-asset convention used in mock data. Wiring is live; nothing renders until badges are seeded and assigned.Description on Next/Related cards is plumbed but not yet rendered.
_post_card_itemexposesentry.summaryasdescriptionon each card dict, but_post_card.html(the in-flight PostFeed work) doesn't read the field yet. Once that include lands, the cards will start showing the AI summary automatically with no further changes on this side.The next/related card date format needs to be updated in
_post_card.html. The post-header now uses the written-out format (e.g. "November 11th, 2024"), but the cards still renderd/m/Ybecause the format lives in the shared_post_card.htmlinclude.The admin actions UI needs a design decision. The Approve, Pending Moderation, Edit, and Delete controls don't have a Figma spec in V3, so the layout (left-aligned row above the post header) and styling (v3 button component with
green/secondary/error) are developer judgment. Worth a design review before this ships broadly.Screenshots
@henryajisegiri we're going to need design for these buttons
Self-review Checklist
Frontend